studio/src/pages/[organizationSlug]/webhook-history.tsx (516 lines of code) (raw):
import { RefreshInterval } from "@/components/analytics/refresh-interval";
import { useApplyParams } from "@/components/analytics/use-apply-params";
import {
useAnalyticsQueryState,
useDateRangeQueryState,
} from "@/components/analytics/useAnalyticsQueryState";
import { CodeViewer } from "@/components/code-viewer";
import {
DatePickerWithRange,
DateRangePickerChangeHandler,
} from "@/components/date-picker-with-range";
import { EmptyState } from "@/components/empty-state";
import { getDashboardLayout } from "@/components/layout/dashboard-layout";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Loader } from "@/components/ui/loader";
import { Pagination } from "@/components/ui/pagination";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Spacer } from "@/components/ui/spacer";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableWrapper,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/components/ui/use-toast";
import { useFeatureLimit } from "@/hooks/use-feature-limit";
import { formatDateTime } from "@/lib/format-date";
import { createDateRange, msToTime } from "@/lib/insights-helpers";
import { NextPageWithLayout } from "@/lib/page";
import { cn } from "@/lib/utils";
import { useMutation, useQuery } from "@connectrpc/connect-query";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { CheckIcon, UpdateIcon } from "@radix-ui/react-icons";
import { keepPreviousData } from "@tanstack/react-query";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { EnumStatusCode } from "@wundergraph/cosmo-connect/dist/common/common_pb";
import {
getWebhookDeliveryDetails,
getOrganizationWebhookHistory,
redeliverWebhook,
} from "@wundergraph/cosmo-connect/dist/platform/v1/platform-PlatformService_connectquery";
import { GetOrganizationWebhookHistoryResponse } from "@wundergraph/cosmo-connect/dist/platform/v1/platform_pb";
import { formatISO } from "date-fns";
import { useRouter } from "next/router";
const WebhookDeliveryDetails = ({ refresh }: { refresh: () => void }) => {
const router = useRouter();
const { toast } = useToast();
const detailsId = router.query.details as string;
const { data, error, isLoading, refetch } = useQuery(
getWebhookDeliveryDetails,
{
id: detailsId,
},
{
enabled: !!detailsId,
},
);
const { mutate, isPending } = useMutation(redeliverWebhook, {
onSuccess: (data) => {
if (data.response?.code === EnumStatusCode.OK) {
toast({
description: "Webhook redelivery attempted",
duration: 2000,
});
refresh();
} else {
toast({
description: data.response?.details,
duration: 2000,
});
}
},
onError: () => {
toast({
description: `Could not attempt redelivery`,
duration: 2000,
});
},
});
let content;
if (isLoading) {
content = <Loader fullscreen />;
} else if (
error ||
data?.response?.code !== EnumStatusCode.OK ||
!data.delivery
) {
content = (
<EmptyState
icon={<ExclamationTriangleIcon />}
title="Could not retrieve delivery details"
description={
data?.response?.details || error?.message || "Please try again"
}
actions={<Button onClick={() => refetch()}>Retry</Button>}
/>
);
} else {
const details = data.delivery;
content = (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-1 items-center gap-x-2 rounded-md border border-input p-2">
<Badge>POST</Badge>
<code className="w-full truncate break-all text-xs">
{details.endpoint}
</code>
</div>
<Button
variant="secondary"
className="w-full md:w-auto"
isLoading={isPending}
onClick={() => {
mutate({
id: details.id,
});
}}
>
Redeliver
</Button>
</div>
<TableWrapper>
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Status</TableHead>
<TableHead>Event</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Retries</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>
{formatDateTime(new Date(details.createdAt))}
</TableCell>
<TableCell>
{details.responseStatusCode || details.responseErrorCode}
</TableCell>
<TableCell>
<Badge variant="secondary">{details.eventName}</Badge>
</TableCell>
<TableCell>{msToTime(details.duration)}</TableCell>
<TableCell>{details.retryCount}</TableCell>
</TableRow>
</TableBody>
</Table>
</TableWrapper>
<div className="text-sm text-muted-foreground">
Triggered by {details.createdBy ?? "unknown user"}
</div>
<Tabs className="mt-2" defaultValue="request">
<TabsList>
<TabsTrigger value="request">Request</TabsTrigger>
<TabsTrigger value="response" className="gap-x-2">
Response
</TabsTrigger>
</TabsList>
<TabsContent autoFocus={false} value="request" className="px-1">
<h3 className="mb-2 mt-6 text-base font-semibold tracking-tight">
Headers
</h3>
<div className="scrollbar-custom overflow-auto rounded border">
<CodeViewer
disableLinking
code={details.requestHeaders}
language="json"
/>
</div>
<h3 className="mb-2 mt-6 text-base font-semibold tracking-tight">
Payload
</h3>
<div className="scrollbar-custom overflow-auto rounded border">
<CodeViewer
disableLinking
code={details.payload}
language="json"
/>
</div>
</TabsContent>
<TabsContent autoFocus={false} value="response" className="px-1">
{details.errorMessage && (
<>
<h3 className="mb-2 mt-6 text-base font-semibold tracking-tight">
Error
</h3>
<div className="rounded border px-3 py-2 font-mono text-xs">
{details.errorMessage}
</div>
</>
)}
<h3 className="mb-2 mt-6 text-base font-semibold tracking-tight">
Headers
</h3>
<div className="scrollbar-custom overflow-auto rounded border">
<CodeViewer
disableLinking
code={details.responseHeaders || ""}
language="json"
/>
</div>
{JSON.parse(details.responseBody || "{}") && (
<>
<h3 className="mb-2 mt-6 text-base font-semibold tracking-tight">
Body
</h3>
<div className="scrollbar-custom overflow-auto rounded border">
<CodeViewer
disableLinking
code={details.responseBody || "{}"}
language="json"
/>
</div>
</>
)}
</TabsContent>
</Tabs>
</div>
);
}
return (
<Sheet
modal
open={!!detailsId}
onOpenChange={(isOpen) => {
if (!isOpen) {
const newQuery = { ...router.query };
delete newQuery["details"];
router.replace({
query: newQuery,
});
}
}}
>
<SheetContent className="scrollbar-custom w-full max-w-full overflow-y-scroll sm:max-w-full md:max-w-2xl lg:max-w-3xl">
<SheetHeader className="mb-4">
<SheetTitle className="flex items-center gap-2">
Details{" "}
{data?.delivery?.isRedelivery && (
<Badge variant="muted">redelivery</Badge>
)}
</SheetTitle>
</SheetHeader>
{content}
</SheetContent>
</Sheet>
);
};
const WebhookHistoryPage: NextPageWithLayout = () => {
const router = useRouter();
const pageNumber = router.query.page
? parseInt(router.query.page as string)
: 1;
const limit = Number.parseInt((router.query.pageSize as string) || "10");
const {
dateRange: { start, end },
range,
} = useDateRangeQueryState();
const { refreshInterval } = useAnalyticsQueryState();
const type = (router.query.type as string) || "";
const { data, isLoading, error, isFetching, refetch } = useQuery(
getOrganizationWebhookHistory,
{
pagination: {
limit: limit > 50 ? 50 : limit,
offset: (pageNumber - 1) * limit,
},
dateRange: {
start: formatISO(range ? createDateRange(range).start : start),
end: formatISO(range ? createDateRange(range).end : end),
},
filterByType: type,
},
{
placeholderData: keepPreviousData,
refetchInterval: refreshInterval,
},
);
const applyParams = useApplyParams();
const onDateRangeChange: DateRangePickerChangeHandler = ({
dateRange,
range,
}) => {
if (range) {
applyParams({
range: range.toString(),
dateRange: null,
page: "1",
});
} else if (dateRange) {
const stringifiedDateRange = JSON.stringify({
start: formatISO(dateRange.start),
end: formatISO(dateRange.end ?? dateRange.start),
});
applyParams({
range: null,
dateRange: stringifiedDateRange,
page: "1",
});
}
};
const historyRetention = useFeatureLimit("analytics-retention", 7);
const noOfPages = Math.ceil((data?.totalCount || 0) / limit);
const columnHelper =
createColumnHelper<
GetOrganizationWebhookHistoryResponse["deliveries"][number]
>();
const columns = [
columnHelper.display({
id: "status",
size: 40,
header: () => <div className="w-4"></div>,
cell: (ctx) => {
const statusCode = ctx.row.original.responseStatusCode;
const isSuccess = !!statusCode && statusCode >= 200 && statusCode < 300;
return (
<div className="flex justify-center">
{isSuccess ? (
<CheckIcon className="h-4 w-4 text-success" />
) : (
<ExclamationTriangleIcon className="h-4 w-4 text-destructive" />
)}
</div>
);
},
}),
columnHelper.accessor("createdAt", {
header: () => <div>Time</div>,
cell: (ctx) => formatDateTime(new Date(ctx.getValue())),
}),
columnHelper.accessor("type", {
header: () => <div>Type</div>,
cell: (ctx) => <div>{ctx.getValue()}</div>,
}),
columnHelper.accessor("eventName", {
header: () => <div>Event</div>,
cell: (ctx) => <Badge variant="secondary">{ctx.getValue()}</Badge>,
}),
columnHelper.accessor("responseStatusCode", {
header: () => <div>Status</div>,
cell: (ctx) => {
const statusCode = ctx.row.original.responseStatusCode;
const isSuccess = !!statusCode && statusCode >= 200 && statusCode < 300;
return (
<div
className={cn(
"flex items-center gap-x-2",
!isSuccess && "text-destructive",
)}
>
<span>
{ctx.row.original.responseStatusCode ||
ctx.row.original.responseErrorCode}
</span>
</div>
);
},
}),
columnHelper.accessor("duration", {
header: () => <div>Duration</div>,
cell: (ctx) => {
return <span>{msToTime(ctx.getValue())}</span>;
},
}),
columnHelper.accessor("retryCount", {
header: () => <div>Retries</div>,
}),
];
const table = useReactTable({
data: data?.deliveries ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
manualPagination: true,
});
const onRefreshIntervalChange = (value?: number) => {
applyParams({
refreshInterval: value ? value.toString() : null,
});
};
return (
<div className="flex h-full flex-col gap-y-4">
<div className="flex flex-wrap items-center gap-2 md:justify-end">
<DatePickerWithRange
range={range}
dateRange={{ start, end }}
onChange={onDateRangeChange}
calendarDaysLimit={historyRetention}
/>
<Select
value={type}
onValueChange={(val) =>
applyParams({
type: val || null,
})
}
>
<SelectTrigger className="w-max">
<SelectValue></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="">All Types</SelectItem>
<SelectItem value="webhook">Webhook</SelectItem>
<SelectItem value="slack">Slack</SelectItem>
<SelectItem value="admission">Admission</SelectItem>
</SelectContent>
</Select>
<Spacer />
<Button
isLoading={isLoading || isFetching}
size="icon"
variant="outline"
onClick={() => refetch()}
>
<UpdateIcon />
</Button>
<RefreshInterval
value={refreshInterval}
onChange={onRefreshIntervalChange}
/>
</div>
<TableWrapper className="max-h-full">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead
style={{
width: `${header.getSize()}px`,
}}
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
onClick={() => {
applyParams({
details: row.original.id,
});
}}
className="group cursor-pointer hover:bg-secondary/30"
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
{isLoading && <Loader className="my-12" />}
{!isLoading &&
(error || data?.response?.code !== EnumStatusCode.OK) && (
<EmptyState
icon={<ExclamationTriangleIcon />}
title="Could not retrieve history"
description={
data?.response?.details || error?.message || "Please try again"
}
actions={<Button onClick={() => refetch()}>Retry</Button>}
/>
)}
{data?.deliveries.length === 0 && (
<p className="w-full p-8 text-center text-sm italic text-muted-foreground">
No history found
</p>
)}
</TableWrapper>
<Pagination limit={limit} noOfPages={noOfPages} pageNumber={pageNumber} />
<WebhookDeliveryDetails refresh={refetch} />
</div>
);
};
WebhookHistoryPage.getLayout = (page) => {
return getDashboardLayout(
page,
"Webhook History",
"Track all webhooks that are fired in your organization",
);
};
export default WebhookHistoryPage;